Skip to content

fix(swipe): resolve infinite re-render loop causing page freeze#3475

Merged
xiyehutao merged 2 commits into
feat_v4.xfrom
fix/issue-3433
Jun 4, 2026
Merged

fix(swipe): resolve infinite re-render loop causing page freeze#3475
xiyehutao merged 2 commits into
feat_v4.xfrom
fix/issue-3433

Conversation

@xiaoyatong
Copy link
Copy Markdown
Collaborator

@xiaoyatong xiaoyatong commented Jun 3, 2026

fix(swipe): resolve infinite re-render loop causing page freeze

Closes #3433

🤔 这个变动的性质是?

  • 日常 bug 修复

🔗 相关 Issue

💡 需求背景和解决方案

问题现象

用户在 H5 环境下使用 Swipe 组件,进入页面即卡死(无限重渲染导致页面冻结)。

根因分析

修复涉及两类 bug:

Bug 1:useCallback 依赖不稳定导致无限循环(仅 H5 端 swipe.tsx

leftRef / rightRef 使用了 useCallback + ref callback 模式来测量左右操作区域宽度,但依赖数组写的是 [props.leftAction] / [props.rightAction]。这两个 prop 是 React 元素(JSX),每次父组件渲染都会产生新的引用。导致:

  1. 父组件渲染 → props.leftAction 新引用 → leftRef callback 重建
  2. leftRef 变化 → React 调用 ref callback → setActionWidth 触发 state 更新
  3. state 更新 → 重渲染 → 回到步骤 1 → 无限循环

Bug 2:opened ref 误用为布尔值(H5 + Taro 双端)

openeduseRef(false) 创建的 ref 对象,代码中有两处直接使用了 !openedopened 而非 opened.current。ref 对象本身是一个永远存在的对象(truthy),所以 !opened 永远是 falseopened 永远是 true,导致:

  • onTouchMoveisEdge 判断逻辑错误,滑动边界检测失效
  • togglebaseNum 始终为 1 - 0.3 = 0.7,开合阈值判断不随 open/close 状态变化

修复方案

改动 文件 说明
useCallback 依赖改为 [] swipe.tsx 稳定 callback 引用,阻断无限循环
新增 actionWidthRef swipe.tsx 用 ref 追踪最新 actionWidth,在 callback 内做等值比较,宽度不变时不触发 setState
!opened!opened.current swipe.tsx + swipe.taro.tsx 修正 ref 取值(2 处)
openedopened.current swipe.tsx + swipe.taro.tsx 修正 ref 取值(2 处)

兼容性说明

  • API 无变化:不涉及任何 props、回调、ref 暴露方法的变更,纯内部逻辑修正
  • 行为修正:修复后 opened 状态判断恢复正确,滑动开合阈值(30% / 70%)会随组件状态正确切换。之前始终按已打开状态计算阈值(0.7),修复后未打开时用 30%、已打开时用 70%
  • Taro 端:Taro 端的宽度测量走的是 createSelectorQuery(不是 ref callback),不存在无限循环问题,但 opened 的 ref 误用同样存在,本次一并修复
  • 向后兼容:修复不改变任何公开 API,不影响现有使用方式

☑️ 请求合并前的自查清单

⚠️ 请自检并全部勾选全部选项⚠️

  • 文档已补充或无须补充
  • 代码演示已提供或无须提供
  • TypeScript 定义已补充或无须补充
  • fork仓库代码是否为最新避免文件冲突
  • Files changed 没有 package.json lock 等无关文件

Summary by CodeRabbit

发布说明

  • Bug Fixes

    • 修复了滑动手势中的状态判定逻辑,确保边缘识别和打开/关闭状态正确
    • 优化了动作区域宽度的状态管理,消除了回调闭包中的陈旧值问题
  • Tests

    • 扩展了滑动交互的测试覆盖,新增左右滑动触发、关闭状态验证和禁用状态处理等场景测试

- Fix useCallback refs with unstable deps (props.leftAction/rightAction)
  that triggered setActionWidth on every render, causing an infinite loop
- Add width equality check before updating state to break the cycle
- Fix `opened` ref accessed without `.current` in both H5 and Taro versions

Closes #3433

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@github-actions github-actions Bot added the action:review This PR needs more reviews (less than 2 approvals) label Jun 3, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 3, 2026

Review Change Stack

Walkthrough

该PR将 Swipe 组件中对打开状态的判断由 opened 改为实时的 opened.current,Web 版引入 actionWidthRef 并重写宽度测量回调以防闭包陈旧值,同时新增触摸交互测试覆盖(开/关与 disabled 场景)。

Changes

Swipe组件状态与闭包修复

Layer / File(s) Summary
Taro版本打开状态引用修复
src/packages/swipe/swipe.taro.tsx
onTouchMoveisEdge 判定与 togglebaseNum 的计算由 opened 改为 opened.current
Web版本打开状态与闭包陈旧值修复
src/packages/swipe/swipe.tsx
新增 actionWidthRef 并在渲染时同步写入;isEdgetoggle 改用 opened.current;重写 leftRef/rightRef 为无依赖回调,仅在宽度变化时更新 actionWidth
测试:触摸交互覆盖
src/packages/swipe/__tests__/swipe.spec.tsx
新增多项触摸交互测试(右滑/左滑打开、打开后滑回关闭、disabled 不响应),并对 getRect 进行 mock;更新测试导入。

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • irisSong
  • oasis-cloud

Poem

🐰 轻踩叶间尘,滑动不再惧,
opened.current 持真念,闭包旧影散去,
宽度悄变才更新,测试轻声作证,
Swipe 再次跳跃,界面欢喜如初。

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed PR标题清晰准确地概括了主要变更:修复了导致页面冻结的无限重渲染循环问题。
Description check ✅ Passed PR描述完整详细,包括问题现象、根因分析、修复方案及兼容性说明,并完整填写了所有检查清单项。
Linked Issues check ✅ Passed 代码变更完全对应#3433的需求:修复useCallback依赖问题和opened ref误用,消除无限重渲染,修复触摸交互逻辑。
Out of Scope Changes check ✅ Passed 所有变更均在修复范围内:swipe.tsx和swipe.taro.tsx的核心逻辑修复,以及相应测试用例补充,无出界变更。
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-3433

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented Jun 3, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (feat_v4.x@5cc31d1). Learn more about missing BASE report.

Additional details and impacted files
@@             Coverage Diff              @@
##             feat_v4.x    #3475   +/-   ##
============================================
  Coverage             ?   88.33%           
============================================
  Files                ?      295           
  Lines                ?    19747           
  Branches             ?     3117           
============================================
  Hits                 ?    17443           
  Misses               ?     2298           
  Partials             ?        6           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Add tests for swipe open/close via touch events and disabled state
to cover the useCallback ref, onTouchMove, and toggle logic paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@pull-request-size pull-request-size Bot added size/L and removed size/M labels Jun 4, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/packages/swipe/__tests__/swipe.spec.tsx (1)

102-145: ⚡ Quick win

getRect 的恢复放到 afterEachfinally

现在 spy.mockRestore() 都放在断言后面;一旦前面的断言失败,mock 会泄漏到后续 case,测试会串掉。统一用 afterEach(() => jest.restoreAllMocks()) 会更稳。

🔧 可参考的调整
 import * as getRectModule from '`@/utils/get-rect`'
+
+afterEach(() => {
+  jest.restoreAllMocks()
+})
 
 test('swipe right to open via touch', () => {
   const spy = jest.spyOn(getRectModule, 'getRect').mockReturnValue({
@@
 
   expect(onOpen).toHaveBeenCalled()
-  spy.mockRestore()
 })

Also applies to: 148-190, 194-255

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/packages/swipe/__tests__/swipe.spec.tsx` around lines 102 - 145, The test
creates a jest spy on getRect (const spy = jest.spyOn(getRectModule, 'getRect'))
and calls spy.mockRestore() only after assertions, which can leak mocks if a
test fails; move mock cleanup into a shared afterEach by removing per-test
spy.mockRestore() calls and adding afterEach(() => jest.restoreAllMocks()) (or
ensure each test wraps spy creation in try/finally and always calls
spy.mockRestore()); update tests referencing getRect/spy across the file
(including the other ranges noted) to rely on the centralized afterEach cleanup
instead of per-test restores.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/packages/swipe/__tests__/swipe.spec.tsx`:
- Around line 101-255: Add explicit boundary tests for the Swipe thresholds:
when getRect (mocked via getRectModule.getRect) returns width = 80, add one test
that starts closed, performs a touchStart at x=200 then touchMove to x=168 (≈40%
drag / 32px) and touchEnd and assert onOpen was called; add another test that
starts opened (simulate initial open by performing the >70% drag first as in
existing "Open first" steps) then touchStart at the opened position and
touchMove back to x=168 (≈40%) and touchEnd and assert onClose was NOT called,
then touchMove back further to x=144 (≈70% / 56px) and touchEnd and assert
onClose was called; reference the existing tests' use of getRectModule.getRect,
the Swipe component and the wrapper queried via '.nut-swipe', and the
onOpen/onClose mocks when adding these boundary assertions.

---

Nitpick comments:
In `@src/packages/swipe/__tests__/swipe.spec.tsx`:
- Around line 102-145: The test creates a jest spy on getRect (const spy =
jest.spyOn(getRectModule, 'getRect')) and calls spy.mockRestore() only after
assertions, which can leak mocks if a test fails; move mock cleanup into a
shared afterEach by removing per-test spy.mockRestore() calls and adding
afterEach(() => jest.restoreAllMocks()) (or ensure each test wraps spy creation
in try/finally and always calls spy.mockRestore()); update tests referencing
getRect/spy across the file (including the other ranges noted) to rely on the
centralized afterEach cleanup instead of per-test restores.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c0991142-773c-4378-b2c0-e03c5d4799fd

📥 Commits

Reviewing files that changed from the base of the PR and between c783bce and a938cf8.

📒 Files selected for processing (1)
  • src/packages/swipe/__tests__/swipe.spec.tsx

Comment on lines +101 to +255
test('swipe right to open via touch', () => {
const spy = jest.spyOn(getRectModule, 'getRect').mockReturnValue({
width: 80,
height: 40,
top: 0,
left: 0,
right: 80,
bottom: 40,
})

const onOpen = jest.fn()
const { container } = render(
<Swipe
rightAction={
<Button type="primary" shape="square">
删除
</Button>
}
onOpen={onOpen}
>
<Cell title="滑动" radius={0} />
</Swipe>
)

const wrapper = container.querySelector('.nut-swipe') as HTMLElement

act(() => {
fireEvent.touchStart(wrapper, {
touches: [{ clientX: 200, clientY: 0, pageX: 200, pageY: 0 }],
})
})
act(() => {
fireEvent.touchMove(wrapper, {
touches: [{ clientX: 100, clientY: 0, pageX: 100, pageY: 0 }],
})
})
act(() => {
fireEvent.touchEnd(wrapper, {
changedTouches: [{ clientX: 100, clientY: 0 }],
})
})

expect(onOpen).toHaveBeenCalled()
spy.mockRestore()
})

test('swipe left to open via touch', () => {
const spy = jest.spyOn(getRectModule, 'getRect').mockReturnValue({
width: 80,
height: 40,
top: 0,
left: 0,
right: 80,
bottom: 40,
})

const onOpen = jest.fn()
const { container } = render(
<Swipe
leftAction={
<Button type="success" shape="square">
选择
</Button>
}
onOpen={onOpen}
>
<Cell title="滑动" radius={0} />
</Swipe>
)

const wrapper = container.querySelector('.nut-swipe') as HTMLElement

act(() => {
fireEvent.touchStart(wrapper, {
touches: [{ clientX: 100, clientY: 0, pageX: 100, pageY: 0 }],
})
})
act(() => {
fireEvent.touchMove(wrapper, {
touches: [{ clientX: 200, clientY: 0, pageX: 200, pageY: 0 }],
})
})
act(() => {
fireEvent.touchEnd(wrapper, {
changedTouches: [{ clientX: 200, clientY: 0 }],
})
})

expect(onOpen).toHaveBeenCalled()
spy.mockRestore()
})

test('swipe close after opened', () => {
const spy = jest.spyOn(getRectModule, 'getRect').mockReturnValue({
width: 80,
height: 40,
top: 0,
left: 0,
right: 80,
bottom: 40,
})

const onClose = jest.fn()
const { container } = render(
<Swipe
rightAction={
<Button type="primary" shape="square">
删除
</Button>
}
onClose={onClose}
>
<Cell title="滑动" radius={0} />
</Swipe>
)

const wrapper = container.querySelector('.nut-swipe') as HTMLElement

// Open first
act(() => {
fireEvent.touchStart(wrapper, {
touches: [{ clientX: 200, clientY: 0, pageX: 200, pageY: 0 }],
})
})
act(() => {
fireEvent.touchMove(wrapper, {
touches: [{ clientX: 100, clientY: 0, pageX: 100, pageY: 0 }],
})
})
act(() => {
fireEvent.touchEnd(wrapper, {
changedTouches: [{ clientX: 100, clientY: 0 }],
})
})

// Close by swiping back
act(() => {
fireEvent.touchStart(wrapper, {
touches: [{ clientX: 100, clientY: 0, pageX: 100, pageY: 0 }],
})
})
act(() => {
fireEvent.touchMove(wrapper, {
touches: [{ clientX: 200, clientY: 0, pageX: 200, pageY: 0 }],
})
})
act(() => {
fireEvent.touchEnd(wrapper, {
changedTouches: [{ clientX: 200, clientY: 0 }],
})
})

expect(onClose).toHaveBeenCalled()
spy.mockRestore()
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

补上阈值临界区间的断言。

这里三组手势都在 width = 80 时使用了 100px 位移,已经同时超过了关闭态 30% 和打开态 70% 两个阈值。这样即使 opened 仍然被误用为 ref 对象,这些用例也会继续通过,锁不住这次 opened.current 修复的回归。建议至少补一组边界值断言:关闭态拖到约 40% 应该打开;已打开态回拖约 40% 不应关闭,超过约 70% 才关闭。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/packages/swipe/__tests__/swipe.spec.tsx` around lines 101 - 255, Add
explicit boundary tests for the Swipe thresholds: when getRect (mocked via
getRectModule.getRect) returns width = 80, add one test that starts closed,
performs a touchStart at x=200 then touchMove to x=168 (≈40% drag / 32px) and
touchEnd and assert onOpen was called; add another test that starts opened
(simulate initial open by performing the >70% drag first as in existing "Open
first" steps) then touchStart at the opened position and touchMove back to x=168
(≈40%) and touchEnd and assert onClose was NOT called, then touchMove back
further to x=144 (≈70% / 56px) and touchEnd and assert onClose was called;
reference the existing tests' use of getRectModule.getRect, the Swipe component
and the wrapper queried via '.nut-swipe', and the onOpen/onClose mocks when
adding these boundary assertions.

@xiyehutao xiyehutao merged commit 5f4a35a into feat_v4.x Jun 4, 2026
7 checks passed
@xiyehutao xiyehutao deleted the fix/issue-3433 branch June 4, 2026 04:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

action:review This PR needs more reviews (less than 2 approvals) size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Swipe组件卡死

2 participants